如何实现 JsBridge
前言
对于移动端应用来说,跨平台 和 动态化 一直是两个痛点。在过去的十几年的技术发展历程中,Cordova、Weex、RN、Flutter、Compose Multiplatform 等框架层出不穷。这里且不讨论框架的孰优孰劣,不同的技术团队,不同的历史背景,做出的技术选型都不尽相同。但对于一些并不那么在意性能的非核心页面,简单快速的 H5 一定会成为一些技术团队的跨平台方案选择。
所有跨平台方案都存在和原生通信的问题,H5 也不例外。一个一个网页被放置在 WebView 容器中,和原生环境隔离,无法直接调用 Native 能力,就需要我们制定好通信协议来供双方通信,通常就称之为 JsBridge 。
基础技术原理
借助于 JavaScript 的语言特性和 WebView 提供的系统能力,原生 Native 和 Web 的通信其实很简单。这里我以 Android 的实现为例简单说明。
Native 调用 Web
Native 调用 Web,直接利用 WebView 的系统能力执行 JS 代码即可。
<script>
function handleMessage(message) {
console.log("handleMessage: " + message)
return "nativeCallWeb"
}
</script>
H5 中定义了 handleMessage()
方法,返回值是一个字符串。现在要从 Native 端去调用的话,可以通过 WebView.evaluateJavascript(String script, ValueCallback<String> resultCallback)
方法。
webView.evaluateJavascript("javascript:window.handleMessage('Hello')") {
Log.e("JsBridge", "receive from web: $it")
}
有一点需要注意,evaluateJavascript()
方法添加于 API 19。如果你不幸的仍然需要适配 Android 4.4 以下的设备,可以通过 WebView.loadUrl()
方法来调用 Web 方法。
webView.loadUrl("javascript:window.handleMessage('Hello')")
loadUrl()
方法是没有回调的,但方法是死的,思路是活的。Native 可以调用 Web,Web 也可以调用 Native 的话,曲线救国,肯定是可以完成回调的。
Web 调用 Native
Android WebView 通过 addJavascriptInterface(Object object, String name)
方法,提供了向 Web 端全局 window 注入对象的能力,通过这个对象可以调用 Native 提供的方法。
addJavascriptInterface(JsBridge(this@MainActivity, webView), "JsBridge")
class JsBridge(private val activity: Activity, private val webView: WebView) {
@JavascriptInterface
fun webCallNative(message: String) {
Log.e("JsBridge", "webCallNative: ${Thread.currentThread().name}")
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
}
}
上面的代码注入了名叫 JsBridge
的全局对象,在 Web 端可以直接这样调用:
window.JsBridge.webCallNative("webCallNative")
注意 Native 端注入对象的方法需要添加 @JavascriptInterface
(API 17 可用)注解,才可以暴露给 Web 调用。
除了这种注入方案之外,还有一种巧妙的方案。如果你使用过开源的 JsBridge ,并且需要实现自定义的 WebViewClient
的话,必须继承它提供的 BridgeWebViewClient
,就是因为它利用 shouldOverrideUrlLoading
巧妙的完成了整个通信流程 。
public boolean shouldOverrideUrlLoading(WebView view, String url) {
try {
url = URLDecoder.decode(url, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
if (url.startsWith(BridgeUtil.YY_RETURN_DATA)) { // 如果是返回数据
webView.handlerReturnData(url);
return true;
} else if (url.startsWith(BridgeUtil.YY_OVERRIDE_SCHEMA)) {
webView.flushMessageQueue();
return true;
} else {
return super.shouldOverrideUrlLoading(view, url);
}
}
上面的代码也直接说明了通信原理,在 Web 端加载携带数据的指定格式的 url,在 Native 端通过 shouldOverrideUrlLoading
拦截并解析以获取数据,完成通信。
Web 端通常使用 iFrame.src
来,开源的 JsBridge 也是如此:
bizMessagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://return/_fetchQueue/' + encodeURIComponent(messageQueueString);
这种方案的隐藏风险是参数过长导致加载的 url 长度超过 WebView 的限制。上面两种方案都是没有直接回调的,无法满足实际使用中双向通信的需求。但正如前面说过的,既然两边的单向通信都是通畅的,那么一来一回的两次单向通信就可以完成双向通信。
双向通信
还是用上面动态注入 JsBridge 的例子:
window.JsBridge.webCallNative("webCallNative")
在 Native 端接收到 webCallNative()
调用之后,再通过 evaluateJavascript()
方法调用 Web,以达到回调的效果。
@JavascriptInterface
fun webCallNative(message: String) {
Log.e("JsBridge", "webCallNative: ${Thread.currentThread().name}")
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
activity.runOnUiThread {
webView.evaluateJavascript("javascript:window.handleMessage('Hello')") {
Log.e("JsBridge", "receive from web: $it")
}
}}
那么问题来了,应该调用 Web 的什么方法呢?或者说,调用 Web 方法之后,H5 如何区分是哪个方法调用的回调呢?
其实也很简单,在 Web 端定义好方法回调 callBack,在每次对 Native 的调用时指定一个 callbackId,通过 map 保存 callbackId 和 callBack 的映射关系。这个 callbackId 要传递到 Native 端,Native端通过 evaluateJavascript
进行反向调用时再把 callbackId 带回来,Web 端根据 callbackId 就可以找到 callback,直接调用即可。直接上代码更容易理解一些。Js 端:
<body>
<p>
<input type="button" value="Web 调用 Native" onclick="webCallNative((value => console.log(value)))"/>
</p>
</body>
<script>
var callbackMap = {};
var callbackId = 1000;
function webCallNative(callback) {
callbackId ++;
// 保存 callbackId 和 callback 的映射关系
callbackMap[callbackId] = callback;
const param = {
message: "webCallNative",
callbackId: callbackId
};
// 将通信数据和 callbackId 传递给 native
window.JsBridge.webCallNative(JSON.stringify(param))
}
</script>
Native 端:
@JavascriptInterface
fun webCallNative(message: String) {
val jsonObject = JSONObject(message)
// 取出 Web 传递过来的 callbackId
val callbackId = jsonObject.optInt("callbackId")
val callbackJsonObj = JSONObject().apply {
put("callbackId", callbackId)
put("message", "Hello")
}
val json = callbackJsonObj.toString()
activity.runOnUiThread {
// 把 callbackId 和自己的业务数据发送给 Web 的 handleMessage() 方法
webView.evaluateJavascript("javascript:window.handleMessage('$json')") {
Log.e("JsBridge", "receive from web: $it")
}
}
}
再来到 Web 端:
<script>
var callbackMap = {};
var callbackId = 1000;
function webCallNative(callback) {
callbackId ++;
// 保存 callbackId 和 callback 的映射关系
callbackMap[callbackId] = callback;
const param = {
message: "webCallNative",
callbackId: callbackId
};
// 将通信数据和 callbackId 传递给 native
window.JsBridge.webCallNative(JSON.stringify(param))
}
function handleMessage(message) {
// 解析 Native 传递的数据
var param = JSON.parse(message.toString());
var message = param.message;
// 获取 callbackId
var callbackId = param.callbackId;
// 获取 callbackId 从 callbackMap 中取出之前存入的 callback
var callback = callbackMap[callbackId];
// 执行 callback
callback(message);
return "nativeCallWeb"
}
</script>
同样,Native 对 Web 的带回调的调用也是一样,只不过角色互换,在 Native 端保存 callBackId 和 callBack 的对应关系。至此,带回调的双向通信就完成了。但是要作为基础设施供团队使用,还差一些火候。
JsBridge 还应该具备哪些能力
混乱不堪的 Bridge 方法
在以往使用开源 JsBridge 的过程中,往往都是各个原生技术团队自行与 H5 约定 Bridge 方法,这样就存在以下几个问题:
Bridge 方法混乱不堪,方法定义不尽合理,比如同一功能不同团队之间重复约定。 没有完善文档支持造成方法定义不明确,甚至几次交接之后找不到 Bridge 方法的具体含义 针对一些基础通用的 Bridge 方法,比如 key-value 数据的存取,页面的跳转,分享功能等等,无法形成直接可用的基础能力
当然,这也并不全是框架的问题,但我们可以尝试从源头解决问题,通过向 Web 端提供 Js Sdk 的形式,将 Bridge 方法的定义规范化 。
怎么理解 Js Sdk 呢?用伪代码表示一下:
var api = {
putKV: function (e) { ... },
getKV: function (e) { ... },
share: function (e) { ... }
}
只有在 Js Sdk 中事先定义好的 Bridge 方法,才可以正常调用。固定成员负责维护 Js Sdk 和方法说明文档,从源头上解决了混乱不堪的 Bridge 方法。Js SDK 本身也要防止越加越乱,最好遵循这几个原则:
业务无关的公用 Bridge 统一定义,所有团队共同使用 团队独有业务的 Bridge 方法可以考虑单独定义一个 Js Sdk,但也要考虑维护成本 业务方有新增 Bridge 方法的需求时,先判断是否有必要,非必要不添加
现在 Bridge 方法通过 Js SDK 的形式对前端暴露,那么 Native 端应该如何处理呢?我们可以 只通过唯一一个底层 Bridge 方法进行桥接 。
再回到前面 webCallNative()
的例子:
<script>
var callbackMap = {};
var callbackId = 1000;
function webCallNative(callback) {
callbackId ++;
// 保存 callbackId 和 callback 的映射关系
callbackMap[callbackId] = callback;
const param = {
message: "webCallNative",
callbackId: callbackId
};
// 将通信数据和 callbackId 传递给 native
window.JsBridge.webCallNative(JSON.stringify(param))
}
</script>
我们只需要简单改造一下传递的 Json 参数。
const param = {
apiName: "webCallNative", // Bridge 方法名称
callbackId: callbackId, // 回调 id
params: "xxx" // 传递的参数
};
这样通过唯一的桥接方法来传递每一次调用的 方法名称,callbackId,参数,可以更方便的对通信流程做统一处理。在此基础上,就可以解决下一节的问题。
裸露的 Bridge 数据
上面的示例代码中,所有的通信数据都是在裸奔,没有任何安全性。得益于上一节提出的方案,所有通信都被收口到同一个 Bridge 方法,这让我们可以针对通信数据做统一的处理,从而进行数据的完整性校验,或者加解密。具体的安全方案,需要衡量不同方案对通信速度的影响。Js 侧的校验逻辑也可以通过 Native 端 WebView 注入的方式。
理想的接入方式
至此,JsBridge 的核心逻辑其实已经完成了。对于 Web 端,无需区分 iOS 还是 Android,仅需引入 js sdk,然后直接调用 sdk 中已经定义好的方法即可。对于 Native 端,需要再给业务侧提供一个足够好用的 WebView sdk,着重以下几点:
让业务侧可以用足够简洁的,可配置的代码快速展示一个网页 针对通用的,非业务相关的 Bridge 方法,提供基础实现 设计一套简洁易用的机制,让业务侧可以快速对接非公用的 Bridge 方法 针对 WebView 本身的功能完善和优化
总结
JsBridge 的原理很简单,基于注入或者拦截 URL ,但要形成一个可用性良好的 SDK,还是有很多工作可以做的。这里推荐几个开源方案。
拦截 URL 方案:https://github.com/lzyzsd/JsBridge
Android/iOS 通用:https://github.com/wendux/DSBridge-Android
js-sdk 方案:https://github.com/hcanyz/ZJsBridge-ZJs